Skip to main content

v8.2.0

This release adds Advanced Filters (nested AND/OR condition groups with operators such as is_not, greater_than, is_empty) and Saved Views, which replace Index Preferences / Manage Columns. Saved views store filters, sort, visible columns, records per page, and view type for each user.

Breaking Changes​

filter_with Scopes Now Receive a condition Argument​

Every scope referenced by filter_with on a :single_select, :multi_select, :date, or :range filter is now called with two arguments: the filter value and the selected operator (condition).

Filter typeBeforeAfter
:single_select, :multi_selectrecords.send(scope, value)records.send(scope, value, condition)
:date, :rangerecords.send(scope, from, to)records.send(scope, value, condition)
:searchrecords.send(scope, value)Unchanged

The date and range behaviour has changed most:

  • value is now the raw string, for example "2026-01-01 to 2026-01-31" or "18 to 30". Previously the gem split it into from and to.
  • For single-value operators (greater_than, less_than, is, …), value holds a single value with no to.

condition is nil for the standard filter bar. It is set only when the filter is used inside Advanced Filters.

danger

Scopes defined with lambda / -> enforce arity strictly. A scope that still takes one argument (or from, to) raises ArgumentError when filtering. Update every filter_with scope before upgrading.

Available conditions​

Filter typeConditionsDefault
:single_select, :multi_selectis, is_not, is_empty, is_not_emptyis
:datebetween, not_between, is_empty, is_not_emptybetween
:rangebetween, not_between, is, is_not, greater_than, less_than, greater_than_or_equal_to, less_than_or_equal_to, is_empty, is_not_emptybetween

Your scope is still called with is_empty and is_not_empty even when value is blank. Handle these conditions without relying on value.

Migration Steps​

Add a condition parameter to every filter_with scope. Make it optional (condition = nil) so that existing direct callers keep working.

Select filters:

# Before
scope :custom_status_filter, ->(statuses) { where(status: statuses) }

# After
scope :custom_status_filter, lambda { |statuses, condition = nil|
case condition.to_s
when 'is_not' then where.not(status: statuses)
when 'is_empty' then where(status: nil)
when 'is_not_empty' then where.not(status: nil)
else where(status: statuses)
end
}

Date / range filters:

# Before
scope :custom_age_range, lambda { |from, to|
where('age BETWEEN ? AND ?', from.to_i, to.to_i) if from.present? && to.present?
}

# After
scope :custom_age_range, lambda { |value, condition = nil|
from, to = value.to_s.split(' to ')
case condition.to_s
when 'not_between' then where.not(age: from.to_i..to.to_i)
when 'greater_than' then where('age > ?', from.to_i)
when 'is_empty' then where(age: nil)
# ...handle the other operators you want to support
else where(age: from.to_i..to.to_i)
end
}

If your scope only wraps a column with extra joins or a different column, delegate the operator handling to the new helpers. They are available on every ActiveRecord model through CmAdmin::FilterConditions:

# Filter users by the country of their company (users -> companies)
scope :by_company_country, lambda { |value, condition = nil|
left_joins(:company).ct_dropdown_filter_condition(column: :country, value:, condition:, table: 'companies')
}

# Filter users by the size of their company
scope :by_company_size, lambda { |value, condition = nil|
left_joins(:company).ct_range_filter_condition(column: :employee_count, value:, condition:, table: 'companies')
}
filter :company_country, :multi_select, helper_method: :company_country_options, filter_with: :by_company_country
filter :company_size, :range, filter_with: :by_company_size

table: defaults to the model's own table. Pass the joined table's name whenever the column lives on an association.

tip

Use left_joins rather than joins when you support is_empty. With an inner joins, records that have no associated row are dropped before the IS NULL check runs.

HelperHandles
ct_dropdown_filter_condition(column:, value:, condition:, table: table_name)is, is_not, is_empty, is_not_empty. Maps cm_empty to NULL
ct_range_filter_condition(column:, value:, condition:, table: table_name)All date and range operators. Parses the "from to to" string for you

Index Preferences Replaced by Saved Page Views​

The CmIndexPreference model and the cm_index_preferences table have been removed. Column visibility, records per page, filters, sort, and view type are now saved per user as named page views in a new ct_page_views table.

Migration Steps​

  1. Run the CT Page View generator, then migrate:
rails generate cm_admin:ct_page_view
rails db:migrate

The generated migration:

  • Enables the PostgreSQL citext extension if it is not already enabled. The view name is case-insensitive.
  • Drops the cm_index_preferences table if it exists.
  • Creates ct_page_views with a polymorphic owner, name, ar_model_name, associated_ar_model_name, visible_columns, hidden_columns, is_default, view_type, records_per_page, filter_params (jsonb), and sort_params (json).
  • Adds a partial unique index so that each owner has at most one default view per model / associated model.
warning

Existing column preferences in cm_index_preferences are not migrated. Users have to recreate their column layouts as saved views. If you need to keep the data, back up the table or copy it into ct_page_views before running the migration.

  1. Remove any references to the old index preference API from your application:
RemovedReplacement
CmIndexPreference modelCtPageView
cm_index_preference_<model>_path route helpercreate_ct_page_view_<model>_path / update_ct_page_view_<model>_path
reset_cm_index_preference_<model>_path route helperClear the active view (?clear_ct_page_view=true)
cm_index_preference / reset_cm_index_preference controller actionslist_ct_page_view, create_ct_page_view, update_ct_page_view, destroy_ct_page_view, duplicate_ct_page_view
CmAdmin::ViewHelpers::IndexPreferencePopupHelperPartials under cm_admin/page_view/ and cm_admin/main/_manage_columns_modal
@cm_index_preference instance variable@current_ct_page_view
  1. Make sure the following are present in your host app. Most cm-admin installs already have them:

    • The DEFAULT_PER_PAGE constant (generated in config/initializers/constants.rb). CtPageView references it at load time. PER_PAGE_OPTIONS is optional and defaults to [10, 20, 50, 100].
    • Current.user is set for admin requests. It becomes the owner of each saved view.
    • PaperTrail's versions table. CtPageView declares has_paper_trail.
  2. Remove cm_index_preferences from db/schema.rb / structure.sql once migrated, and remove any factories, seeds, or specs that build CmIndexPreference records.

New routes​

These routes are added automatically for every cm-admin model, with no setup needed:

VerbPathAction
GET/<models>/ct_page_viewslist_ct_page_view
POST/<models>/ct_page_viewscreate_ct_page_view
PUT/<models>/ct_page_views/:idupdate_ct_page_view
DELETE/<models>/ct_page_views/:iddestroy_ct_page_view
POST/<models>/ct_page_views/:id/duplicateduplicate_ct_page_view

All actions are scoped to owner: Current.user, so users can only see and change their own views.

Default views​

A user can mark one view per list page as default. When that user opens the index page with no filters, current_ct_page_view_id, clear_filters, or clear_ct_page_view params, they are redirected to their default view. Deep links that include any of those params are not affected.

Behaviour Changes​

filters URL Param Is Now Compressed​

The filters query param on index and dashboard pages is now a zlib-compressed, base64url-encoded JSON string (produced with pako on the frontend), not a nested filters[...] hash. Compressing it keeps URLs short now that Advanced Filters can contain nested groups.

  • Old links still work. A nested filters[...] hash or a plain JSON string is still accepted.
  • If your application code reads params[:filters] directly (custom controllers, helpers, table_source_methods, export hooks), decode it first:
# Before
filters = JSON.parse(params[:filters] || '{}')

# After
filters = CmAdmin::Utils.decode_filter_params(params[:filters]) || {}
  • To generate a filtered link from Ruby, encode the hash:
cm_admin.cm_index_user_path(filters: CmAdmin::Utils.encode_filter_params({ single_select: { status: 'active' } }))

Dashboard widget source methods (charts, metrics, tables) still receive a decoded filters hash, so no change is needed there.

If your config.sidebar entries open a pre-filtered index page through query:, prefer moving that default into the filter's default_value: option instead of passing filters in the URL:

# Before — sidebar entry carrying a filters hash
{ display_name: 'Draft Posts', path: 'cm_index_post_path',
query: { filters: { single_select: { status: 'draft' } } } }

# After — the filter applies itself whenever no filter params are present
filter :status, :single_select, collection: %w[draft published], default_value: -> { 'draft' }

A filters hash in query: also suppresses the default-view redirect on every visit, so saved views never apply when the page is opened through that entry.

If a sidebar entry must keep its query: — for example, several entries pointing at different filtered states of the same index — include the current user's default view keys so the page resolves the correct view context:

{
display_name: 'Posts',
path: 'cm_index_post_path',
query: lambda {
view = CtPageView.find_by(owner: Current.user, is_default: true, ar_model_name: 'Post')
{
current_ct_page_view_id: view&.id,
filters: view&.filter_params
}
}
}

query accepts a proc that is evaluated on each request, which is required here because Current.user is only set while rendering. filter_params is a plain hash, so it serializes back into the nested filters[...] URL format, which is still supported.

tip

CtPageView#apply_params returns the same keys (current_ct_page_view_id, filters, sort, view_type) already URL-ready, so query: -> { CtPageView.find_by(...)&.apply_params || {} } is a shorthand for the above.

Advanced Filters Override Standard Filters​

When a request contains advanced filter conditions, all standard filter keys (date, range, single_select, multi_select) and their default_values are ignored. Only search and advanced are applied. Exports follow the same rule. If you rely on a default_value filter always being applied, enforce it with a policy scope or default scope instead.

Model#default_filters Signature​

default_filters now also accepts a filter_list: keyword, alongside the existing action:. Existing calls such as model.default_filters(action:) keep working.

available_fields Uses Indifferent Access​

CmAdmin::Model#available_fields is now a HashWithIndifferentAccess. Both available_fields[:index] and available_fields['index'] work. No action is needed.

Checklist​

  1. Update every application filter_with scope for select, date, and range filters to accept (value, condition = nil).
  2. Run rails generate cm_admin:ct_page_view && rails db:migrate. Back up cm_index_preferences first if you need the data.
  3. Remove references to CmIndexPreference, cm_index_preference_* / reset_cm_index_preference_* route helpers, and @cm_index_preference.
  4. Confirm that DEFAULT_PER_PAGE, Current.user, and PaperTrail are available.
  5. Replace any direct JSON.parse(params[:filters]) with CmAdmin::Utils.decode_filter_params(params[:filters]).
  6. Review sidebar query: entries that inject filters — prefer default_value: on the filter, or include current_ct_page_view_id and filters from the user's default CtPageView.